Skip to content

S03-06 核心类-字符串

[TOC]

String

类的声明

java
 // JDK8 中的 String 源码
 public final class String
     implements java.io.Serializable, Comparable<String>, CharSequence {
     // String 对象的字符内容是存储在此数组中。
     // final:此 value 数组一旦初始化,其地址不可变。
     private final char value[];
  
     /** Cache the hash code for the string */
     private int hash; // Default to 0
}
  • final:表示 String 类不可被继承
  • Serializable可序列化的接口。凡是实现此接口的类的对象可以通过网络或本地进行数据的传输
  • Comparable:凡是实现此接口的类的对象可以比较大小
  • CharSequence字符序列

底层实现演进

底层实现的演进 (Java 8 vs Java 9+):

随着 Java 版本的迭代,String底层数据结构进行过一次重大的优化:

JDK 8 及之前 (char[])

底层是一个被 final 修饰的字符数组 private final char value[];。由于 Java 中的 char 占用 2 个字节(UTF-16 编码),即使你只存储英文字母,它也会占用 2 个字节,造成了一定的内存浪费。

java
// JDK8 中的 String 源码
public final class String
  implements java.io.Serializable, Comparable<String>, CharSequence {
  private final char value[];  // 1. 字符数组(2byte)
}

JDK 9 及之后 (byte[] + 编码标志)

为了节省内存(Compact Strings 特性),底层变成了字节数组 private final byte[] value;,并增加了一个标志位 private final byte coder;

  • 如果字符串只包含 Latin-1 字符(绝大多数英文场景),coder 为 0,每个字符只占 1 个字节。
  • 如果包含中文或其他 Unicode 字符,coder 为 1,退化为使用 UTF-16 编码,每个字符占 2 个字节。
java
// JDK9 中的 String 源码
public final class String
  implements java.io.Serializable, Comparable<String>, CharSequence {
  @Stable
  private final byte[] value; // 1. 字节数组(1byte)

  private final byte coder; // 2. 标志位
}

内存结构:字符串常量池

内存管理:字符串常量池 (String Pool):

JVM 为了提升性能和减少内存开销,专门在堆内存(Heap)中开辟了一块区域叫做字符串常量池字符串常量池中不允许存放两个相同的字符串常量。

示例:字面量方式创建字符串

java
String s1 = "hello";
String s2 = "hello";
  • 机制:JVM 会首先检查常量池中是否存在 "hello"。如果不存在,则在池中创建一个并返回引用;如果存在(如 s2 的情况),则直接返回池中已有对象的引用。
  • 结果s1 == s2true,它们指向内存中的同一个对象。

字符串常量池在内存中的存放位置

  • JDK6 及之前:字符串常量池在方法区

    image-20260509143205201

  • JDK7 及之后:就移到堆空间,直到目前的 JDK17 版本。

    image-20260509143540680

    image-20260509143402636

不可变性

核心特性:不可变性 (Immutability):

String 最重要的设计哲学就是不可变。这意味着一个 String 对象一旦在内存中被创建,它的内容就永远不能被改变。当你对字符串进行拼接、裁剪或替换时,实际上都是在内存中创建了一个新的 String 对象,而原对象保持不变。

示例:String 的不可变性(重新赋值)

java
String s1 = "hello";
String s2 = "hello";
s2 = "hi";

image-20260509144402976


示例:String 的不可变性(拼接)

当你对字符串进行拼接时,实际上都是在内存中创建了一个新的 String 对象,而原对象保持不变。

java
String s1 = "hello";
String s2 = "hello";
s2 += "world";

image-20260509145118885


示例:String 的不可变性(替换)

java
String s1 = "hello";
String s2 = "hello";
String s3 = s2.replace('l', 'w');

image-20260509145814418

为什么 String 要被设计成不可变?

  • 线程安全 (Thread Safety): 因为状态无法改变,String 天生是线程安全的。多个线程可以放心地共享同一个 String 对象,无需进行同步操作。
  • 支持字符串常量池 (String Pool): 如果字符串可变,改变其中一个变量的值会导致其他指向该内存的变量也被意外修改,常量池的存在就失去了意义。
  • 安全性 (Security): 字符串经常被用作网络连接地址、文件路径、数据库 URL 等。如果 String 是可变的,黑客就可以在系统进行安全检查后修改字符串内容,引发安全漏洞。
  • 高效缓存 Hash 码: String 内部缓存了其 hash 值(hash 属性)。因为不可变,其 hash 值也永远不变,这使得 String 极其适合作为 HashMap 的 key,效率极高。

练习:下列程序运行的结果

java
public class StringTest {
    String str = new String("good");
    char[] ch = { 't', 'e', 's', 't' };

    public void change(String str, char ch[]) {
        str = "test ok";
        ch[0] = 'b';
    }

    public static void main(String[] args) {
        StringTest ex = new StringTest();
        ex.change(ex.str, ex.ch);
        System.out.print(ex.str + " and "); // good and
        System.out.println(ex.ch); // best
    }
}

创建字符串

创建字符串主要有两种方式,它们在内存中的表现完全不同:

方式一:字面量赋值 (推荐)

java
String s1 = "hello";
String s2 = "hello";
  • 机制:JVM 会首先检查常量池中是否存在 "hello"。如果不存在,则在池中创建一个并返回引用;如果存在(如 s2 的情况),则直接返回池中已有对象的引用。
  • 结果s1 == s2true,它们指向内存中的同一个对象。

方式二:new 关键字

java
String s3 = new String("hello");
  • 机制:只要看到 new,JVM 就一定会在堆内存(Heap)中创建一个全新的 String 对象。同时,它还会检查常量池中是否有 "hello",如果没有,它还会在常量池中创建一个字面量对象。
  • 结果s1 == s3falses3 指向堆中的新对象,而 s1 指向常量池中的对象。

示例:对比 String 实例

image-20260509150251519


示例:new String("hello") 时创建了2个内存对象(只计算向栈暴露的对象)

image-20260509150951772


示例:为对象属性赋值字符串

image-20260509152035842

image-20260509152615301

连接符 +

在 Java 中,+ 号除了用于数字的加法运算外,还是字符串的连接符

+ 号是 Java 语言中唯一被重载的运算符(Java 本身不支持开发者自定义运算符重载)。为了让开发者用得爽,Java 编译器和 JVM 在底层为 + 号做了大量的工作和不断地演进优化。

隐式类型转换

当你使用 + 将一个字符串与其他任何类型(基本类型或对象)连接时,Java 会自动将非字符串类型转换为字符串

  • 基本数据类型:直接转换为对应的字符串表达形式(如 1 变成 "1"true 变成 "true")。
  • 对象类型:底层会自动调用该对象的 toString() 方法。
  • null 的特殊处理:这是极其容易踩坑的地方。如果参与拼接的对象是 null,Java 不会抛出空指针异常(NullPointerException),而是**将其转换为字符串 "null"**
java
String s1 = "Version: " + 17;        // "Version: 17"
String s2 = "Result: " + true;       // "Result: true"
Object obj = null;
String s3 = "Data: " + obj;          // "Data: null" (注意这里不是空字符串,而是四个字母的 "null")

常量折叠

常量折叠 (Constant Folding):如果 + 号连接的两边都是编译期可以确定的字符串常量(或者是由 final 修饰的常量),Java 编译器会在编译阶段直接把它们拼接好,而不会等到运行期再去处理。

java
String s1 = "Hello" + " " + "World";

底层真相:在编译后的 .class 字节码文件中,"Hello" + " " + "World" 根本不存在。编译器直接将其优化成了 String s1 = "Hello World";

  • 性能影响:完全没有运行时的拼接开销。
  • 内存机制:拼接后的 "Hello World" 会直接放入字符串常量池。

变量拼接演进

如果 + 号连接的包含变量,编译器就无法在编译期进行优化了。这时候,底层到底发生了什么?随着 Java 版本的升级,这里的机制发生了翻天覆地的变化。

假设有如下代码:

java
String a = "hello";
String b = "world";
String c = a + ", " + b;

JDK8 及之前隐式的 StringBuilder

在 Java 8 时代,编译器遇到变量拼接,会自动在底层帮你创建一个 StringBuilder 对象,然后调用 append() 方法,最后调用 toString() 转换回 String

上述代码在 Java 8 底层等效于:

java
// 每次执行到这行,都会 new 一个 StringBuilder,最后 new 一个 String
String c = new StringBuilder().append(a).append(", ").append(b).toString();
  • 缺点:生成的字节码较长。且如果未来 JVM 想要优化拼接逻辑,必须要重新编译 .class 文件。

JDK9 及之后invokedynamic 与动态拼接 (JEP 280)

为了解决 Java 8 的痛点,Java 9 引入了重大革新。编译器不再硬编码生成 StringBuilder,而是生成一条特殊的字节码指令:invokedynamic

  • 机制:编译器把拼接的具体实现推迟到了运行期。运行时,JVM 会通过 StringConcatFactory 工厂类,动态生成一个高度优化的方法句柄(MethodHandle)来执行拼接。
  • 优势
  1. 极大地减少了字节码的大小。

  2. 极其灵活:JVM 可以在底层使用比 StringBuilder 更高效的算法(例如直接预先计算最终长度,分配一次 byte[] 然后填充字节),而不需要你重新编译代码。现代 JVM 对此的优化已经让简单的 + 号拼接性能极具竞争力。


示例:对比拼接后的字符串

image-20260509155645045


示例:final 修饰后变量会变成常量

image-20260509160140306


示例:使用 concat() 连接字符串

无论参数是常量还是变量,也不论调用者是常量还是变量,concat() 底层都会调用 new 创建新对象。

image-20260509160430825

严禁循环中使用 +

尽管 Java 9+ 对 + 号做了动态优化,但在循环中使用 + 号拼接字符串,依然是 Java 开发中的大忌。

反面教材:

java
String result = "";
for (int i = 0; i < 10000; i++) {
  result = result + i; // ⚠️ 灾难性的写法
}

为什么这是灾难?

  1. 在 Java 8 中,每次循环都会 new StringBuilder() 然后 toString() (又 new 了一个 String)。10000 次循环就是创建了 20000 个对象。

  2. 在 Java 9+ 中,虽然 invokedynamic 优化了单次拼接,但在循环中,它依然需要不断地创建新的、越来越大的临时 String 对象。

  3. 后果:会产生海量的内存垃圾,导致 GC(垃圾回收器)频繁触发,程序性能直线下降。

正确写法:StringBuilder 提取到循环外部!

java
StringBuilder sb = new StringBuilder(); // 只创建一次
for (int i = 0; i < 10000; i++) {
  sb.append(i);
}
String result = sb.toString();

总结

  1. 单行内的简单拼接:放心大胆地使用 + 号!例如 String msg = "User " + name + " logged in";。在现代 Java 中,不仅代码可读性好,底层性能也已经被极致优化,完全不需要强行写成 StringBuilder

  2. 避免 null:在拼接前,如果不确定对象是否为空,注意防范拼出 "null" 字符串带来的业务逻辑 bug。

  3. 严禁在循环中使用 +:只要涉及在 forwhile循环体内对同一个字符串变量进行不断累加拼接,必须手动使用StringBuilder

== vs equals()

致命考点:==equals():

这是处理 Java 字符串时最容易踩坑的地方:

  • ==:比较的是内存地址(即两个引用是否指向物理内存中的同一个对象)。
  • equals():被 String 类重写过,比较的是字符串的字面内容是否相同。

最佳实践: 在 Java 中比较两个字符串的内容,永远使用 equals()equalsIgnoreCase(),绝不要使用 ==,除非你明确知道你在比较引用。

API:String

Java 的 String 类提供了极其丰富的 API,为了便于记忆和理解,我们可以按照它们的功能将这些常用 API 分为 8 大类。

⚠️ 核心前提:由于 String 是不可变的,以下所有看起来像是在“修改”字符串的方法(如截取、替换、大小写转换等),实际上都没有改变原字符串,而是返回了一个全新的字符串对象

构造器

构造器

  • String()()不推荐,初始化一个新创建的 String 对象,使其表示一个空字符序列(等同于 "")。

  • String()(String original)不推荐,根据传入的字符串对象,在堆内存中创建一个它的副本(新对象)。

  • String()(char[] value),将整个字符数组转换为字符串。

  • String()(char[] value, int offset, int count),将字符数组的一部分转换为字符串。offset 是起始索引,count 是截取的字符个数。

  • String()(byte[] bytes)危险写法,使用平台默认的字符集(通常是 UTF-8,但 Windows 中文版默认可能是 GBK)对字节数组进行解码。
    严重警告:极度危险!如果你的代码在 Linux (默认 UTF-8) 上开发,跑到 Windows (GBK) 上运行,这段代码极大概率会产生乱码。生产环境中强烈不建议使用这个构造器。

  • String()(byte[] bytes, String charsetName)

    String()(byte[] bytes, Charset charset),使用指定的字符集字节数组进行解码。

  • String()(byte[] bytes, int offset, int length, Charset charset),对字节数组的指定部分,使用指定字符集进行解码。适用于网络数据包接收时,只解析有效载荷部分。

java
String s = new String();

String s = new String("hello");

char[] chars = {'J', 'a', 'v', 'a'};
String s = new String(chars); // 输出: "Java"

char[] chars = {'H', 'e', 'l', 'l', 'o'};
String s = new String(chars, 1, 3); // 从索引 1 开始,取 3 个字符。输出: "ell"

byte[] bytes = {97, 98, 99}; // ASCII 码
// 推荐写法 (Java 7 引入的 StandardCharsets,避免拼写错误)
String s = new String(bytes, StandardCharsets.UTF_8); // 输出: "abc"

长度与字符获取

长度与字符获取 (Length & Character Access):

这些方法用于获取字符串的基本物理信息或提取单个字符。

  • int length()(),返回字符串的长度(字符个数)

  • char charAt()(int index),返回指定索引处的字符。索引从 0 开始。

  • char[] toCharArray()(),将此字符串转换为一个新的字符数组

  • static String valueOf()(Object obj)

    static String valueOf()(Object obj,int offset, int count),将各种类型(如 int, double, boolean, 对象等)安全地转换为字符串。如果传入 null,会返回字符串 "null" 而不会报错。

  • static String copyValueOf()(char[] data)

    static String copyValueOf()(char[] data,int offset, int count),同 valueOf(...),返回指定数组中表示该字符序列的字符串。

java
String str = "Hello Java";
System.out.println(str.length());       // 输出: 10
System.out.println(str.charAt(1));      // 输出: 'e'
char[] chars = str.toCharArray();       // ['H', 'e', 'l', 'l', 'o', ...]
System.out.println(String.valueOf(new char[]{'a','b','c'})); // abc
System.out.println(String.copyValueOf(new char[]{'a','b','c'})); // abc

字符串比较

字符串比较 (Comparison):

除了上一节提到的 equals(),还有几个常用的比较方法。

  • boolean equals()(Object anObject)比较内容是否完全相同(区分大小写)。

  • boolean equalsIgnoreCase()(String anotherString)比较内容是否相同(忽略大小写)。

  • int compareTo()(String anotherString)按字典顺序挨个比较两个字符串。如果当前字符串在前面返回负数,在后面返回正数,相等返回 0。

  • int compareToIgnoreCase()(String anotherString)按字典顺序挨个比较两个字符串(忽略大小写)。

java
String s1 = "java";
System.out.println(s1.equals("JAVA"));             // false
System.out.println(s1.equalsIgnoreCase("JAVA"));   // true
System.out.println("ab".compareTo("ad"));            // -2 (因为 'b' 的 ASCII 码比 'd' 小 2)

查找与包含

查找与包含 (Searching & Checking):

用于检索特定字符或子串在字符串中的位置,或者判断是否包含。

  • boolean contains()(CharSequence s),判断是否包含指定的子串。

  • int indexOf()(String str),返回子串第一次出现的索引。找不到则返回 -1

  • int lastIndexOf()(String str),返回子串最后一次出现的索引。找不到则返回 -1

  • boolean startsWith()(String prefix),判断字符串是否以指定的前缀开始。

  • boolean endsWith()(String suffix),判断字符串是否以指定的后缀结束。

java
String filePath = "image.png";
System.out.println(filePath.contains("age"));     // true
System.out.println(filePath.indexOf("a"));         // 2
System.out.println(filePath.endsWith(".png"));     // true

截取与拆分

截取与拆分 (Substring & Splitting):

  • String substring()(int beginIndex),从指定索引截取到末尾。

  • String substring()(int beginIndex, int endIndex)截取指定区间的子串。
    注意Java 中表示区间都是左闭右开区间 [beginIndex, endIndex),即包含 beginIndex,不包含 endIndex。

  • String[] split()(String regex),根据给定的正则表达式将字符串拆分为数组。

java
String text = "HelloWorld";
System.out.println(text.substring(5));       // "World"
System.out.println(text.substring(0, 5));    // "Hello" (索引 0 到 4)

String data = "apple,banana,orange";
String[] fruits = data.split(",");           // ["apple", "banana", "orange"]

避坑指南split(".") 是无效的,因为 . 在正则表达式中代表任意字符。必须使用转义:split("\\.")

修改与替换

修改与替换 (Modification & Replacement):

  • String replace()(CharSequence target, CharSequence replacement),替换指定的字符或字符串(替换所有匹配项,不支持正则)。

  • String replaceAll()(String regex, String replacement),使用正则表达式替换所有匹配项。

  • String replaceFirst()(String regex, String replacement)使用正则表达式替换第一个匹配项。

  • String toLowerCase()()全部转换为小写

  • String toUpperCase()(),全部转换为大写

  • String trim()(),去除字符串首尾的 ASCII 空白字符(如空格、制表符、换行)。

  • String strip()()JDK11,去除字符串首尾的空白字符。比 trim() 更强大,能识别各种语言的 Unicode 空白字符(如全角空格)。

java
String s = "  Java 17  ";
System.out.println(s.trim());                // "Java 17"
System.out.println(s.replace("17", "21"));   // "  Java 21  "

判空检查

判空检查 (Empty / Blank Checks):

  • boolean isEmpty()(),判断字符串长度是否为 0(即 ""new String()new String(""))。
    注意:如果字符串是 null,调用此方法会抛出 NullPointerException

  • boolean isBlank()()JDK11,判断字符串是否为空,或者仅仅包含空白字符(如 " " 长度为 3,isEmpty() 为 false,但 isBlank() 为 true)。

java
String emptyStr = "";
String blankStr = "   ";
System.out.println(emptyStr.isEmpty());  // true
System.out.println(blankStr.isEmpty());  // false
System.out.println(blankStr.isBlank());  // true (Java 11+)

格式化与拼接

格式化与拼接 (Formatting & Joining):

  • static String format()(String format, Object... args),使用类似 C 语言 printf 的风格格式化字符串。

  • static String join()(CharSequence delimiter, CharSequence... elements)J8,使用指定的分隔符将多个字符串连接起来。

  • String concat()(String str)字符串拼接的另一种选择。

  • String intern()(),尝试将当前字符串对象放入字符串常量池中,并返回常量池中该字符串的引用

java
// 格式化:占位符 %s (字符串), %d (整数), %.2f (保留两位小数的浮点数)
String msg = String.format("User: %s, Age: %d", "Alice", 25);
// 输出: "User: Alice, Age: 25"

// 拼接
String path = String.join("/", "usr", "local", "bin");
// 输出: "usr/local/bin"

类型转换

类型转换 (Conversion)

情况1:字符串 <-> 基本数据类型/包装类

  • static String valueOf()(Object obj),将各种类型(如 int, double, boolean, 对象等)安全地转换为字符串。如果传入 null,会返回字符串 "null" 而不会报错。

  • 包装类.parseXxx()(str),将 String 类型解析为基本数据类型

image-20260509162936993


情况2:字符串 <-> 字符数组 char[]

  • String()(char[] value),将整个字符数组转换为字符串

  • char[] toCharArray()(),将此字符串转换为新的字符数组

image-20260509163329272


情况3:字符串 <-> 字节数据 byte[]

  • String()(byte[] bytes)危险写法,使用平台默认的字符集(通常是 UTF-8,但 Windows 中文版默认可能是 GBK)对字节数组进行解码。
    严重警告:极度危险!如果你的代码在 Linux (默认 UTF-8) 上开发,跑到 Windows (GBK) 上运行,这段代码极大概率会产生乱码。生产环境中强烈不建议使用这个构造器。

  • String()(byte[] bytes, String charsetName)

    String()(byte[] bytes, Charset charset),使用指定的字符集字节数组进行解码。

  • byte[] getBytes()()

    byte[] getBytes()(String charsetName)

    byte[] getBytes()(String charsetName),将字符串编码为字节数组(通常用于网络传输或文件写入)。可以传入指定的字符集(如 getBytes(StandardCharsets.UTF_8))。

image-20260509170536959

image-20260509170519888

算法题

题目1:模拟一个trim方法,去除字符串两端的空格。

java
public String myTrim(String str) {
  if (str != null) {
    int start = 0;// 用于记录从前往后首次索引位置不是空格的位置的索引
    int end = str.length() - 1;// 用于记录从后往前首次索引位置不是空格的位置的索引

    while (start < end && str.charAt(start) == ' ') {
      start++;
    }

    while (start < end && str.charAt(end) == ' ') {
      end--;
    }
    if (str.charAt(start) == ' ') {
      return "";
    }

    return str.substring(start, end + 1);
  }
  return null;
}

@Test
public void testMyTrim() {
  String str = "   a   ";
  // str = " ";
  String newStr = myTrim(str);
  System.out.println("---" + newStr + "---");
}

题目2:将一个字符串进行反转。将字符串中指定部分进行反转

比如:abcdefg 反转为 abfedcg

  • 方式一:将 String 转为 char[],针对 char[] 数组反转,反转结束再转为 String

    java
    public String reverse1(String str, int start, int end) {// start:2,end:5
      if (str != null) {
        // 1. 将 String 转为 char[]
        char[] charArray = str.toCharArray();
        // 2. 针对 char[] 数组反转
        for (int i = start, j = end; i < j; i++, j--) {
          char temp = charArray[i];
          charArray[i] = charArray[j];
          charArray[j] = temp;
        }
        // 3. 反转结束再转为 String
        return new String(charArray);
      }
      return null;
    }
  • 方式二:将 str 分为3个部分,分别操作,倒序拼接第2部分,最后再拼接3个部分

    java
    public String reverse2(String str, int start, int end) {
      // 1. 提取 str 的第1部分(ab)
      String newStr = str.substring(0, start);
      // 2. 拼接第2部分(abfedc)
      for (int i = end; i >= start; i--) {
        newStr += str.charAt(i); // ⚠️有性能问题,new 了多次 String 对象,待优化
      }
      // 3. 拼接第3部分(abfedcg)
      newStr += str.substring(end + 1);
      return newStr;
    }
  • 方式三:推荐 (相较于方式二做的改进)

    java
    public String reverse3(String str, int start, int end) {// ArrayList list = new ArrayList(80);
      // 1.
      StringBuffer s = new StringBuffer(str.length());
      // 2.
      s.append(str.substring(0, start));// ab
      // 3.
      for (int i = end; i >= start; i--) {
        s.append(str.charAt(i));
      }
      // 4.
      s.append(str.substring(end + 1));
      // 5.
      return s.toString();
    }
  • 测试

    java
    @Test
    public void testReverse() {
      String str = "abcdefg";
      String str1 = reverse3(str, 2, 5);
      System.out.println(str1);// abfedcg
    }

题目3:获取一个字符串在另一个字符串中出现的次数

比如:获取 ababkkcadkabkebfkabkskab 中出现的次数

java
public int getCount(String mainStr, String subStr) {
  if (mainStr.length() >= subStr.length()) {
    int count = 0;
    int index = 0;
    // while((index = mainStr.indexOf(subStr)) != -1){
    // count++;
    // mainStr = mainStr.substring(index + subStr.length());
    // }
    // 改进:
    while ((index = mainStr.indexOf(subStr, index)) != -1) {
      index += subStr.length();
      count++;
    }
    return count;
  } else {
    return 0;
  }
}

@Test
public void testGetCount() {
  String str1 = "cdabkkcadkabkebfkabkskab";
  String str2 = "ab";
  int count = getCount(str1, str2);
  System.out.println(count);
}

题目4:获取两个字符串中最大相同子串。

比如:str1 = "abcwerthelloyuiodef“; str2 = "cvhellobnm"

思路:将短的那个串进行长度依次递减的子串与较长的串比较。

java
// 如果只存在一个最大长度的相同子串
public String getMaxSameSubString(String str1, String str2) {
  if (str1 != null && str2 != null) {
    String maxStr = (str1.length() > str2.length()) ? str1 : str2;
    String minStr = (str1.length() > str2.length()) ? str2 : str1;

    int len = minStr.length();

    for (int i = 0; i < len; i++) {// 0 1 2 3 4 此层循环决定要去几个字符
      for (int x = 0, y = len - i; y <= len; x++, y++) {
        if (maxStr.contains(minStr.substring(x, y))) {
          return minStr.substring(x, y);
        }
      }
    }
  }
  return null;
}

// 如果存在多个长度相同的最大相同子串
// 此时先返回String[],后面可以用集合中的ArrayList替换,较方便
public String[] getMaxSameSubString1(String str1, String str2) {
  if (str1 != null && str2 != null) {
    StringBuffer sBuffer = new StringBuffer();
    String maxString = (str1.length() > str2.length()) ? str1 : str2;
    String minString = (str1.length() > str2.length()) ? str2 : str1;

    int len = minString.length();
    for (int i = 0; i < len; i++) {
      for (int x = 0, y = len - i; y <= len; x++, y++) {
        String subString = minString.substring(x, y);
        if (maxString.contains(subString)) {
          sBuffer.append(subString + ",");
        }
      }
      System.out.println(sBuffer);
      if (sBuffer.length() != 0) {
        break;
      }
    }
    String[] split = sBuffer.toString().replaceAll(",$", "").split("\\,");
    return split;
  }

  return null;
}
// 如果存在多个长度相同的最大相同子串:使用ArrayList
//	public List<String> getMaxSameSubString1(String str1, String str2) {
//		if (str1 != null && str2 != null) {
//			List<String> list = new ArrayList<String>();
//			String maxString = (str1.length() > str2.length()) ? str1 : str2;
//			String minString = (str1.length() > str2.length()) ? str2 : str1;
//
//			int len = minString.length();
//			for (int i = 0; i < len; i++) {
//				for (int x = 0, y = len - i; y <= len; x++, y++) {
//					String subString = minString.substring(x, y);
//					if (maxString.contains(subString)) {
//						list.add(subString);
//					}
//				}
//				if (list.size() != 0) {
//					break;
//				}
//			}
//			return list;
//		}
//
//		return null;
//	}

@Test
public void testGetMaxSameSubString() {
  String str1 = "abcwerthelloyuiodef";
  String str2 = "cvhellobnmiodef";
  String[] strs = getMaxSameSubString1(str1, str2);
  System.out.println(Arrays.toString(strs));
}

题目5:对字符串中字符进行自然顺序排序

提示:

  1. 字符串变成字符数组。

  2. 对数组排序,选择,冒泡,Arrays.sort();

  3. 将排序后的数组变成字符串。

java
// 第5题
@Test
public void testSort() {
  String str = "abcwerthelloyuiodef";
  char[] arr = str.toCharArray();
  Arrays.sort(arr);

  String newStr = new String(arr);
  System.out.println(newStr);
}

案例:模拟用户登录

image-20260509221624554

image-20260509221648651

image-20260509222829052

StringBuffer / StringBuilder

在 Java 的字符串处理领域,如果说 String 是不可变的基石,StringBuffer 是带有时代局限性的老前辈,那么 StringBuilder 是现代 Java 开发中处理动态字符串的“绝对主力”与“性能王者”。

StringBuilder 是 JDK 1.5 专门为了解决 StringBuffer 性能低下而引入的类。

如果说 String 是不可变的静态文本,那么 StringBuffer 就是一个可变、且线程安全的字符串工作台,而 StringBuilder 是一个 可变、但线程不安全 的字符串工作台。

可变的字符序列

可变的字符序列:与 String 每次修改都会产生新对象不同,StringBuffer / StringBuilder 的核心价值在于原地修改

  • 底层结构:它内部维护了一个字符数组,String 不同,该数组没有使用 final 修饰,因此它是可变的。

    • Java 8 及之前是 char[]

      java
      abstract class AbstractStringBuilder implements Appendable, CharSequence {
        char[] value;  // 底层真正存储字符的数组(没有 final 修饰)
        int count; // 实际使用的字符数量(也就是 length() 方法返回的值)
      }
    • Java 9 及之后为了优化内存改为了 byte[] 加上编码标志位。

      java
      abstract class AbstractStringBuilder implements Appendable, CharSequence {
        byte[] value;     // char[] 变成了 byte[](没有 final 修饰)
        byte coder;       // 新增了编码标志位 (0: Latin1 单字节, 1: UTF-16 双字节)
        int count;
      }
  • 可变性:当你对 StringBuffer / StringBuilder 进行追加(append)、插入(insert)或删除(delete)时,都是直接在这个底层的数组上进行操作,而不会像 String 那样频繁创建新的对象。这极大地节约了内存并提升了修改效率。

线程安全性

线程安全:是 StringBuffer 与它后来的“小弟” StringBuilder(JDK 1.5 引入)之间唯一且最根本的区别

  • 机制:如果你查看 StringBuffer 的源码,会发现它的核心修改方法(如 appendinsertdelete 甚至 charAt 等)都在方法签名上加了 synchronized 关键字。
  • 意义:这意味着它内置了同步锁。当多个线程同时尝试修改同一个 StringBuffer 对象时,JVM 会保证同一时刻只有一个线程能执行修改操作。
  • 代价:加锁和释放锁是极其消耗系统资源的(上下文切换、锁竞争等)。因此,StringBuffer 虽然安全,但性能较低

自动扩容

既然是在原数组上操作,那么当追加的字符超过数组大小怎么办?这就涉及到了面试常考的扩容机制

  1. 初始容量:当你通过 new StringBuffer()new StringBuilder() 创建对象时,如果没有指定大小,它默认会创建一个容量为 16 的空数组(如果有初始字符串,容量为字符串长度 + 16)。

    java
    // StringBuilder 的无参构造源码
    public StringBuilder() {
      super(16);  // 调用父类构造器,默认容量为 16
    }
    
    // StringBuilder 的传参构造源码
    public StringBuilder(String str) {
      super(str.length() + 16); // 容量 = 传入字符串的长度 + 16
      append(str);
    }
    java
    // 父类对应的构造器实现:
    AbstractStringBuilder(int capacity) {
      value = new char[capacity]; // 在内存中开辟指定大小的数组
    }
  2. 触发扩容:当调用 append() 发现当前数组剩余空间不足以容纳新字符时,就会触发扩容。

    java
    public AbstractStringBuilder append(String str) { 
        if (str == null)
            return appendNull(); // 如果是 null,追加 "null" 四个字符
        int len = str.length();
    
        // 1. 检查容量并扩容 (极其关键)
        ensureCapacityInternal(count + len);
    
        // 2. 将传入的字符串拷贝到自身的 value 数组中
        str.getChars(0, len, value, count);
    
        // 3. 更新实际长度
        count += len;
        return this;
    }
    java
    private void ensureCapacityInternal(int minimumCapacity) {
        // overflow-conscious code
        // 如果需要的最小容量 > 当前数组的长度,则触发扩容
        if (minimumCapacity - value.length > 0) {
            value = Arrays.copyOf(value, newCapacity(minimumCapacity)); // 实际执行扩容
        }
    }
  3. 数组拷贝:JVM 会在内存中开辟一块符合新容量的数组,并使用原生的 System.arraycopy() 方法将老数组的数据复制到新数组中,最后丢弃老数组。

    💡 性能优化建议:如果你能预估要拼接的字符串大概有多长,最好在创建时直接指定容量,例如 new StringBuffer(1024)new StringBuilder(1024)。这能避免底层频繁触发扩容和数组拷贝,大幅提升性能。

  4. 扩容算法:通常情况下,新的容量会是当前容量的 2 倍再加上 2(即 newCapacity = oldCapacity * 2 + 2)。如果扩容 2 倍后,依然不够,则直接扩容到所需的实际大小。

    java
    private int newCapacity(int minCapacity) {
        // 核心扩容公式:新容量 = 老容量 * 2 + 2
        // (源码中用位移运算 << 1 代替 * 2,效率更高)
        int newCapacity = (value.length << 1) + 2;
    
        // 如果扩容 2 倍后,依然比所需的 minCapacity 小
        if (newCapacity - minCapacity < 0) {
            newCapacity = minCapacity; // 直接扩容到所需的实际大小
        }
        // ... 省略大容量校验逻辑
        return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
            ? hugeCapacity(minCapacity)
            : newCapacity;
    }

链式编程

链式编程StringBuffer / StringBuilder 的修改方法(如 append, insert, replace, reverse 等)在执行完毕后,都会返回当前对象自身的引用(return this;。这使得我们可以写出极其流畅的代码:

java
// 链式编程示例
String result = new StringBuffer()
    .append("SELECT * FROM users ")
    .append("WHERE age > ").append(18)
    .append(" AND status = 'ACTIVE'")
    .insert(0, "/* Auto Generated */\n") // 在开头插入注释
    .toString(); // 最终输出为 String

System.out.println(result);

API:StringBuffer / StringBuilder

StringBuffer / StringBuilder 的核心价值在于对字符串进行高频修改。为了实现这一点,它提供了一套非常丰富的 API。

由于 StringBufferStringBuilder 的 API 是完全一致的(唯一的区别仅仅是 StringBuffer 的方法加了 synchronized 锁),因此掌握了这些 API,就等于同时掌握了 StringBuilder

追加与插入

这是日常开发中最频繁使用的操作。与 String+ 号或 concat 产生新对象不同,这些方法都是在原对象的基础上进行扩充,并且都会返回当前对象的引用(this),因此非常适合链式编程

  • StringBuffer append()(Object obj),将各种类型的数据(如 String, int, char, boolean, char[] 甚至自定义对象)追加到当前字符序列的末尾
    如果传入对象,底层会自动调用对象的 toString() 方法。
    如果传入 null,会追加字符串 "null"

    java
    StringBuffer sb = new StringBuffer("User: ");
    // 链式编程
    sb.append("Alice").append(", Age: ").append(25).append(true);
    System.out.println(sb); // 输出: "User: Alice, Age: 25true"
  • StringBuffer insert()(int offset, Object obj),将数据插入到指定的索引位置 offsetoffset 必须大于等于 0 且小于等于当前长度。

    java
    StringBuffer sb = new StringBuffer("HelloWorld");
    sb.insert(5, " Java ");
    System.out.println(sb); // 输出: "Hello Java World"

删除与修改

用于精准控制和修改缓冲区内部的字符序列。

  • StringBuffer delete()(int start, int end)删除指定区间的字符。注意是左闭右开区间 [start, end),即包含 start 索引的字符,但不包含 end 索引的字符。

    java
    StringBuffer sb = new StringBuffer("0123456");
    sb.delete(2, 5); // 删除索引 2, 3, 4 的字符
    System.out.println(sb); // 输出: "0156"
  • StringBuffer deleteCharAt()(int index)删除指定单个索引位置的字符。

    java
    StringBuffer sb = new StringBuffer("0123456");
    sb.deleteCharAt(2); // 删除索引 2 的字符
    System.out.println(sb); // 输出: "013456"
  • StringBuffer replace()(int start, int end, String str),将 [start, end) 区间的字符替换为新的字符串 str
    注意:替换的字符串长度不需要和被替换的区间长度相等,缓冲区会自动调整大小。

    java
    StringBuffer sb = new StringBuffer("Java is bad");
    sb.replace(8, 11, "awesome!!!");
    System.out.println(sb); // 输出: "Java is awesome!!!"
  • void setCharAt()(int index, char ch),直接修改指定索引处的一个字符。此方法没有返回值(void)。

    java
    StringBuffer sb = new StringBuffer("Java");
    sb.setCharAt(0, 'j');
    System.out.println(sb); // 输出: "java"

反转操作

这是一个非常经典且实用的 API,在解决很多算法题(如回文串判断、字符串倒序)时是终极利器。

  • StringBuffer reverse()(),将当前字符序列首尾反转

    java
    StringBuffer sb = new StringBuffer("abcdefg");
    sb.reverse();
    System.out.println(sb); // 输出: "gfedcba"

长度与容量管理

面试中经常会问到 StringBufferlengthcapacity 的区别。

  • int length()(),返回缓冲区中实际存在的字符数量

  • int capacity()(),返回底层数组的总容量(分配的内存大小)

    java
    StringBuffer sb = new StringBuffer(); // 默认创建容量为 16 的数组
    sb.append("Java");
    System.out.println(sb.length());   // 输出: 4  (实际只有4个字符)
    System.out.println(sb.capacity()); // 输出: 16 (底层数组还能装12个字符才需要扩容)
  • void ensureCapacity()(int minimumCapacity),手动确保存储容量至少等于指定的值。
    如果你预先知道要拼接几万个字符,可以提前调用此方法,避免底层发生多次数组扩容和拷贝,提升性能。

  • void trimToSize()()缩减容量。如果你的 StringBuffer 底层容量有 1024,但实际只存了 10 个字符,且以后不再添加了,调用此方法会将底层数组缩小到正好容纳这 10 个字符,释放多余内存。

  • void setLength()(int newLength),强行设置字符序列的长度
    如果 newLength 小于当前长度,相当于截断字符串(后面的字符被丢弃)。
    如果 newLength 大于当前长度,会在末尾追加空字符(\u0000以达到指定长度。
    实用技巧:可以通过 sb.setLength(0);
    快速清空
    一个 StringBuffer 对象,以便重复利用,而不是每次都 new 一个新对象。

转换为不可变字符串

不管你在 StringBuffer 中翻云覆雨做了多少修改,最终往往需要把它变回标准的 String 对象进行传递或输出。

  • String toString()(),将当前的缓冲区内容转换为一个不可变的 String 对象。
    底层机制:在 Java 8 中,它是通过 new String(value, 0, count) 将底层字符数组的内容复制到一个新的 String 中。

过时的 StringBuffer

在现代 Java 开发中,StringBuffer 几乎已经被打入冷宫,极少被使用。为什么?

  1. 绝大多数拼接是单线程的:我们在日常开发中,99% 的字符串拼接逻辑都是发生在方法内部(作为局部变量)。局部变量是线程隔离的,天生就不存在线程安全问题。在这种情况下,使用带锁的 StringBuffer 纯属白白浪费性能。

  2. StringBuilder 是更好的替代品:JDK 1.5 引入的 StringBuilder 拥有与 StringBuffer 完全一致的 API 和扩容机制,唯独去掉了 synchronized。因此,在单线程环境下,StringBuilder 的性能碾压 StringBuffer

  3. 真正的多线程场景有其他方案:如果真的要在高并发多线程环境下共享并拼接文本,开发者往往更倾向于使用并发包(如 ConcurrentLinkedQueue 收集片段,最后单线程合并),或者业务逻辑层面的锁控制,而不是依赖粗粒度的 StringBuffer

总结来说:将 StringBuffer 理解为一个带了安全锁的可变字符串加工厂即可。了解它的底层原理和扩容机制对于扎实 Java 基础非常重要,但在实际写代码时,请毫不犹豫地拥抱 StringBuilder

效率测试

java
//初始设置
long startTime = 0L;
long endTime = 0L;

StringBuffer buffer = new StringBuffer("");
StringBuilder builder = new StringBuilder("");
String text = "";

// 1. 测试 StringBuffer
startTime = System.currentTimeMillis();
for (int i = 0; i < 20000; i++) {
  buffer.append(String.valueOf(i));
}
endTime = System.currentTimeMillis();
System.out.println("StringBuffer的执行时间:" + (endTime - startTime)); // 4

// 2. 测试 StringBuilder
startTime = System.currentTimeMillis();
for (int i = 0; i < 20000; i++) {
  builder.append(String.valueOf(i));
}
endTime = System.currentTimeMillis();
System.out.println("StringBuilder的执行时间:" + (endTime - startTime)); // 2

// 3. 测试 String
startTime = System.currentTimeMillis();
for (int i = 0; i < 20000; i++) {
  text = text + i;
}
endTime = System.currentTimeMillis();
System.out.println("String的执行时间:" + (endTime - startTime)); //870

StringBuilder vs StringBuffer

StringBuilderStringBuffer 就像是一对双胞胎,它们继承自同一个父类(AbstractStringBuilder),底层数据结构、扩容机制、提供的 API 方法完全一模一样

它们之间唯一的、也是最致命的区别在于:StringBuilder 没有加同步锁(synchronized)。

  • 非线程安全:因为没有加锁,如果多个线程同时操作同一个 StringBuilder 对象,可能会导致数据丢失或抛出 ArrayIndexOutOfBoundsException 异常。
  • 极致的高性能:正因为剥离了线程同步的沉重包袱(去掉了获取锁、释放锁、上下文切换的开销),在单线程环境下,StringBuilder 的运行速度远远甩开 StringBuffer

💡 行业现状:在实际开发中,99.9% 的字符串拼接场景都是作为局部变量在单个方法内完成的(属于线程封闭,天生安全)。因此,StringBuilder 已经全面取代了 StringBuffer 的生态位。

最佳实践

掌握 StringBuilder 不仅仅是记住 API,更重要的是知道什么时候该用,什么时候不该用

必用场景:循环拼接

只要你需要在 forwhile 等循环结构中不断累加拼接字符串,必须且只能使用 StringBuilder

java
// 正确做法:在循环外部创建,循环内部追加
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
  sb.append(i).append(",");
}
String result = sb.toString();

如果在这里用 + 号拼接,每次循环都会在内存中创建新的字符串对象,引发灾难性的内存泄漏和 GC 停顿。

性能优化:预估容量

如果你在拼接前,大概知道最终生成的字符串有多长(比如要读取一个 10KB 的文件并拼接成字符串),请务必在构造时指定初始容量

java
// 假设预估数据有 1000 个字符
// 这样可以彻底避免底层数组发生自动扩容和数据拷贝操作,将性能压榨到极致!
StringBuilder sb = new StringBuilder(1024);

禁用场景:单行简单拼接

如果只是在同一行代码里拼接两三个变量,请直接使用 + 号,不要画蛇添足去写 StringBuilder

java
String name = "Alice";
int age = 25;

// ❌ 冗长且没有必要:
String msg1 = new StringBuilder().append("User: ").append(name).append(", Age: ").append(age).toString();

// ✅ 优雅且高效:
String msg2 = "User: " + name + ", Age: " + age;

如前所述,现代 Java 编译器和 JVM 的 invokedynamic 机制对单行 + 号拼接做了极其变态的优化,其底层性能已经不亚于甚至在某些场景下优于手动创建 StringBuilder,且代码可读性完爆后者。

综合对比

为了更直观地理解,我们可以通过下表对比这三个核心类:

特性StringStringBuilderStringBuffer
可变性不可变可变可变
线程安全性安全 (天生)不安全安全 (方法加锁)
性能最低 (拼接时频繁创建对象)最高中等 (锁开销)
底层结构byte[] / char[] (final)byte[] / char[] (可自动扩容)byte[] / char[] (可自动扩容)
适用场景少量操作、常量、不可变数据单线程下的频繁拼接、修改多线程下的频繁拼接、修改

开发建议:

  1. 首选 String:如果你的字符串只需赋值一次,之后只是读取,毫无疑问使用 String。现代 JVM 对 String做了深度优化(比如在 Java 9+ 中,简单的str1 + str2在编译期会被优化为使用invokedynamicStringConcatFactory,性能已经非常优秀,不需要为了拼接两三个字符串特意去写 StringBuilder)。

  2. 循环拼接选 StringBuilder:如果你在一个 forwhile循环中需要不断拼接字符串,必须使用StringBuilder 以防止内存溢出和性能暴跌。

  3. 多线程选 StringBuffer:仅当明确知道有多个线程会同时修改同一个字符串缓冲区时,才使用 StringBuffer

  4. 格式化拼接选 StringJoiner:如果需要处理带分隔符的集合/数组元素拼接,使用 StringJoiner(或 Java 8 Stream API 中的 Collectors.joining())。

练习题

java
String str = null;
StringBuffer sb = new StringBuffer();
sb.append(str);

System.out.println(sb.length()); // 4

System.out.println(sb); // "null"

StringBuffer sb1 = new StringBuffer(str);
System.out.println(sb1); // 空指针异常